Corpus reference enrichment: cross-corpus law linking, authority corpora, corpus-scoped analyzers#1976
Conversation
…law linking, analyzer integration, governance-graph demo
The full reference-web substrate for the governance graph initiative:
Engine (opencontractserver/enrichment/):
- Deterministic extraction of explicit references: law citations in suffix
("Section 145 of the DGCL"), prefix ("Securities Act Section 4(a)(5)"),
statute-internal relative ("§ 251 of this title"), and bare SEC rule
("Rule 506(b)" -> sec-rule:506(b)) forms; document/exhibit references;
internal section references; opt-in defined-term definition sites.
- Resolution to in-corpus targets; idempotent writer producing mention
annotations (dedup by document/label/span-start), OC_REFERENCES
relationships, DocumentRelationship rollups (feeds the corpus document
graph), and CorpusReference rows.
Cross-corpus law linking:
- New CorpusReference model (annotations/0078): cross-document/cross-corpus/
external-law links with indexed canonical_key join key.
- AuthorityCorpusBootstrapper (enrichment/authorities.py): statute sections
as keyed text documents; idempotent with amendment version-up and
self-healing restamp; subsection->section resolution fallback.
- Data-driven authority alias registry from custom_meta.authority_aliases —
adding a body of law is a bootstrap call, zero code changes.
- link_external_references upgrades EXTERNAL citations to RESOLVED
cross-corpus links with in-app link_url; auto-runs inside apply().
Analyzer framework:
- New @corpus_analyzer_task decorator: corpus-scoped sibling of
@doc_analyzer_task (one run per Analysis, task owns its writes, wrapper
manages Analysis lifecycle). Dispatch, auto-sync, and the analyzer.W001
check cover both flavours.
- corpus_reference_enrichment adapter task registers enrichment as a real
task-based Analyzer, dispatchable via CorpusAction.
Surfacing:
- Agent tool pair scan_corpus_references / apply_corpus_reference_enrichment
(approval + write gated); read-only corpusReferences GraphQL query scoped
via CorpusReferenceService.
Demo (demo/): authority seed JSONs with real statutory text (DGCL,
Securities Act, Exchange Act, IRC, ICA, SEC Rules), bootstrap/import/export
scripts, standalone D3 governance-graph visualization and hi-res render.
Proven live across 5 filing corpora (254 documents): 1,943 references
resolved, 1,501 of 1,726 statutory citations linked to in-system law.
110 tests across enrichment + analyzer integration.
Code Review: Corpus Reference Enrichment (#1976)OverviewThis is a large, well-scoped PR (12,318 additions, 46 files) that adds a deterministic corpus reference enrichment engine, a new 🔴 Medium – GraphQL traversal may bypass document-level visibility
Whether this leaks depends on whether corpusReferences(corpusId: "...") {
edges { node { targetDocument { id title content } } }
}could surface document content the user has no direct READ access to. Action required: Confirm that 🔴 Medium –
|
…services/ package, single-source choices - EnrichmentService._load / link_external_references and AuthorityCorpusBootstrapper.bootstrap now fetch the corpus via visible_to_user (uniform DoesNotExist for invisible vs nonexistent — no existence oracle). - _terms() merges parenthetical + means-form matches in document order before applying MAX_DEFINED_TERMS, so parenthetical-heavy documents can no longer starve the means grammar; cap documented as total. - enrichment/service.py split into enrichment/services/ package (corpus_reference_service.py + enrichment_service.py) per the repo-wide <app>/services/ convention; all importers updated; OC_SECTION_LABEL now imported from constants/annotations (dedup). - REFERENCE_TYPE_CHOICES / RESOLUTION_STATUS_CHOICES discriminators single-sourced from enrichment/constants.py (migration-neutral). - corpus_analyzer_task corpus check tightened to analysis-creator visibility via BaseService.filter_visible. - New tests: CorpusReferenceType.targetDocument nulls out for corpus readers lacking document READ (pins graphene-django get_queryset enforcement); link/bootstrap DoesNotExist error paths; defined-term cap boundary in both directions.
Review response (addressed in 17469c0, after merging main in 00e23a0)🔴 GraphQL traversal visibility — Verified: nested FK traversal is framework-enforced. graphene-django 3.2.3's FK converter routes related-object resolution through 🔴 Raw 🟡 🟡 Service location — Moved to 🟡 Choices duplication — Fixed. 🟢 🟢 🟢 Choices defined after the model class — No change: the premise doesn't match the code. Both choice lists are defined immediately before Test coverage gaps — All three covered: traversal-leak test, Verification: 95 tests green across the enrichment + analyzer suites ( Note for follow-up: |
Code Review: Corpus Reference Enrichment (#1976)This is a well-architected, substantial feature. The extract→resolve→write pipeline is clean and clearly separated, the idempotency design is solid, and the test coverage (110 tests) is strong. The changelog fragment and docs are exemplary. A few issues ranging from a potential bug to performance and style concerns below. Potential Bug
analyzer, _ = Analyzer.objects.get_or_create(
id=C.ENRICHMENT_ANALYZER_ID, # "corpus-reference-enrichment"
defaults={
"task_name": C.ENRICHMENT_ANALYZER_TASK,
...
},
)
analyzer, _ = Analyzer.objects.get_or_create(
task_name=C.ENRICHMENT_ANALYZER_TASK,
defaults={
"description": C.ENRICHMENT_ANALYZER_TITLE,
"creator_id": creator_id,
},
)PerformanceN+1 queries in
doc_ids = [d.id for d in documents]
sections_by_doc: dict[int, list[_SectionAnno]] = defaultdict(list)
rows = Annotation.objects.filter(
document_id__in=doc_ids, annotation_label__text=OC_SECTION_LABEL
).values_list("id", "document_id", "raw_text")
for pk, doc_id, txt in rows:
sections_by_doc[doc_id].append(_SectionAnno(id=pk, raw_text=txt or ""))Per-row saves in
Code Style / Maintainability
# current — requires type: ignore and __post_init__ boilerplate
annotation_ids: list[int] = None # type: ignore[assignment]
# idiomatic
annotation_ids: list[int] = field(default_factory=list)
resolution_status = models.CharField(..., default="RESOLVED")Should reference
assert analysis.creator_id is not None # non-null FK; narrows for mypy
The function in Minor IssuesEmpty If if not aliases:
return re.compile(r"(?!)") # never matchesDuplicate-count risk: For text like GraphQLThe new The resolver does not go through Summary
|
…_index (corpuses/0057) makemigrations --check failed on every branch since the CorpusVote model dropped explicit index names; this captures the rename + index-state operations so the check is clean again.
…link) First integration step from the PR callout, unblocked by #1969's merge. Mirrors demo/export_governance_graph.py in-app: - GovernanceGraphService (enrichment/services/governance_graph_service.py) assembles nodes (documents + external-citation ghosts) and mention- weighted LAW / LAW_EXTERNAL / DOCUMENT edges from corpus-as-gate CorpusReference rows + permission-filtered DocumentRelationships. - Visibility: corpus READ gates the query; every surfaced document is independently READ-checked — invisible sources drop their edges, invisible targets degrade to ghost nodes (no title leak), and only visible target corpora are listed. - Resolver in annotation_queries.py only encodes relay ids (E001 green); degree-ranked node cap GOVERNANCE_GRAPH_MAX_NODES=200 with full-graph counts + truncated flag, mirroring corpusDocumentGraph's contract. - Graph vocabulary constants in enrichment/constants.py; 6 tests in test_governance_graph.py incl. ghost-degradation and empty-graph visibility paths.
Code ReviewOverviewThis is a substantial, well-architected PR that adds corpus-level reference enrichment — law citations, document/exhibit references, internal section links, and defined terms — along with cross-corpus authority linking, a new Bugs / Correctness1. The bare # Fix — mirror doc_analyzer_task:
except Retry:
raise
except Exception as e:
analysis.status = JobStatus.FAILED.value
...
raisePerformance2. For a corpus with hundreds of law references this loops over individual saves for both 3. Called in a loop over every document in the corpus. A single prefetch grouped by sections_by_doc = defaultdict(list)
for pk, txt, doc_id in Annotation.objects.filter(
document_id__in=[d.id for d in documents],
annotation_label__text=OC_SECTION_LABEL,
).values_list('id', 'raw_text', 'document_id'):
sections_by_doc[doc_id].append(_SectionAnno(id=pk, raw_text=txt or ''))Code Quality4. This requires annotation_ids: list[int] = field(default_factory=list)
reference_ids: list[int] = field(default_factory=list)5. The function now handles both doc- and corpus-scoped analyzers. Its internal comments and log messages were updated but the function name still says 6. link_url = f"/corpus/{self.corpus.id}/document/{res.target_document_id}"Verify the frontend router accepts raw integer PKs here rather than relay global IDs. From the demo HTML and Minor / Low Priority7. Project convention (CLAUDE.md) is to extend 8. The agent-tool path creates the Analysis already in 9. Import direction Test Coverage: Strong74 new test methods cover: extractor grammar correctness, resolver dispatch, idempotent writes, cross-corpus linking, analyzer lifecycle (RUNNING/COMPLETED/FAILED), auto-sync, authority bootstrapper, governance graph visibility rules. A few notes:
SummaryTwo items worth fixing before merge:
Everything else is style or minor. The architecture is solid, the service layer is properly layered, and the permission model (corpus-as-gate for |
…leanups
- link_url wrote /corpus/{id}/document/{id} — no frontend route serves
that shape (catch-all 404). Writer + link_external_references now emit
the canonical /d/{corpus.creator.slug}/{corpus.slug}/{doc.slug} path
via utils/frontend_paths.document_in_corpus_path; cross-corpus law
links point into the authority corpus; missing slugs skip the link.
- corpus_analyzer_task re-raises celery Retry untouched (it extends
Exception and was being stamped FAILED before the retry ran);
regression test pins RUNNING surviving a retry.
- Perf: OC_SECTION annotations fetched once per corpus (was per doc);
_link_external uses two bulk_updates instead of O(N) saves.
- WriteResult uses field(default_factory=list) (drops type: ignore);
CorpusReferenceService extends BaseService; comment documents the
two Analysis provenance paths in _get_analysis.
Review response (296423e)1. Retry swallowed as permanent failure — Confirmed and fixed. 6. 2. 3. 4. 7. 8. 5. 9. Import-direction comment — Already present since Verification: 188 tests green across the enrichment/analyzer/governance-graph/architecture suites ( |
Code Review — PR #1976: Corpus Reference EnrichmentThis is a large, ambitious PR adding a well-architected new subsystem. The clean separation of concerns (extractor → resolver → writer → services → GraphQL) and thorough test coverage (110 tests) are commendable. Below are findings ranging from bugs to style nits. Bugs1. Missing malformed-ID guard in
def resolve_corpus_references(self, info, corpus_id, **kwargs) -> Any:
pk = int(from_global_id(corpus_id)[1]) # ← raises ValueError on malformed IDIf 2. Analysis marked COMPLETED before writes succeed ( When Performance3. N+1 queries in One 4. N+1 path_records queries in target_corpus_id = (
target.path_records.filter(is_current=True, is_deleted=False)
.values_list("corpus_id", flat=True)
.first()
)This fires one query per distinct resolved target document inside the loop. 5. Single large transaction for the entire write ( All N resolutions are wrapped in one Code Quality6. Import of private name ( from opencontractserver.enrichment.resolver import _SectionAnno
7. assert analysis.creator_id is not None # non-null FK; narrows for mypy
8. Misleading default on resolution_status = models.CharField(…, default="RESOLVED")In practice, newly-created Minor / Style9. The function now creates both doc-scoped and corpus-scoped analyzer rows, but its name is still 10. Corpus node visibility consistency in Source documents from 11. Demo data size The six TestsOverall the test coverage is strong. A few gaps worth addressing:
SummaryThe architecture is sound and the service-layer conventions are followed correctly throughout. The most important things to fix before merge are the malformed-ID 500 bug in the GraphQL resolver and the Analysis lifecycle inconsistency when running via the agent tool. The N+1 queries are a real concern at demo scale (1,943 references) and worth addressing before this ships to production. |
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
The governance-graph demo (bootstrap scripts, EDGAR importers, exported graph artifacts) is a local exploration aid, not production code. Untrack it from the branch and gitignore /demo/ so it stays on disk but out of PRs.
… lifecycle, batch path_records lookup, public SectionAnno, raise-not-assert, visibility-scope governance graph docs
Code Review — PR #1976: Corpus Reference EnrichmentThis is a substantial, well-architected addition. The security posture is strong, the service layer is consistently used, and the test coverage is good for a first pass. A handful of findings below — two bugs and one N+1 are worth addressing before merge; the rest are follow-up quality work. Security — No blocking issues
Bugs1. Regex quote-character class — The defined-term patterns comment says "curly or straight quotes" but the character class 2. Section heading backward-search fallback — idx = doc_text.lower().find(heading.lower(), cand.end)
if idx == -1:
idx = doc_text.lower().find(heading.lower()) # searches from position 0When the heading appears multiple times (e.g., "Section 2. Definitions" in both an exhibit and the main agreement) and the first occurrence is before the reference span, the fallback matches the wrong instance. The resolved Moderate concerns3. N+1 queries in the CorpusReference GraphQL resolver —
return CorpusReferenceService.for_corpus(user, corpus_id).select_related(
"source_annotation", "corpus", "target_document", "target_annotation"
)4. qs = (
Document.objects.all() if user is None else Document.objects.visible_to_user(user)
)Any call without a user (e.g. a future Celery task that forgets to pass 5. Redundant
Minor / polish6. Overly broad except Exception: # unreadable -> rewrite below
current = NoneThe intent appears to be handling "text is not a string", which should be 7. Hardcoded trailing-punctuation strip — The 8. Dispatch gap worth a comment — The PR description flags "CorpusAction auto-bootstrap" as a future step, but Test coverage gaps
Migration notesMigration Summary
The architecture is solid, security practices are consistent with the rest of the codebase, and the |
The visibility manager select_relates parent (added on main), and Django forbids a field being both select_related and deferred via .only(). Clear the manager's JOINs/prefetches before narrowing the node-document query to title/custom_meta. Fixes 5 failing tests in test_governance_graph.py.
- resolver.py: backward-heading fallback now matches the nearest *preceding* occurrence (rfind from cand.start) instead of the first in the document, so a duplicate heading in an earlier exhibit no longer mis-resolves. - annotation_queries.py: select_related the FK targets CorpusReferenceType resolves, removing the N+1 on resolve_corpus_references. - authorities.py: authority_alias_registry(user=None) now contributes only static defaults (Document.objects.none()) rather than aliases from every document — closes a cross-tenant footgun for future callers. - authorities.py: narrow the read_field_file_text except to (OSError, ValueError, AttributeError) so genuine bugs surface. - extractor.py/constants.py: hoist trailing-punctuation literal to C.TRAILING_PUNCT.
…nces side panel Items 2-4 from Open-Source-Legal#1976's integration callout, built on the governanceGraph query: - GovernanceGraphGlimpse: deterministic bipartite d3-force composition — filing clusters above (component-colored, swarm-aware constellation spread), authority-grouped law shelf pinned below with staggered citation-head labels + captions, mention-weighted amber citation arcs, dashed ghosts for not-yet-ingested law. Scale-aware density handling (degree-gated shelf labels, thinned edges, short captions) keeps the 200-node cap legible. Hover focus + cross-corpus click-through. - GovernanceGraphLive owns fetch + the bootstrap CTA: analyzer discovery by task name, immediate corpus analysis, add_document CorpusAction install, weaving poll until first nodes land. - Registered as 'governance-graph' CAML embed + composed into the intelligence overview fallback. - References side panel: new 'references' sidebar tab; Cites (grouped by canonical key with mention counts, ghost rows annotated) / Cited by (grouped by source doc); canonical-link + resolve-by-id navigation via shared useNavigateToDocumentById hook. - Backend: corpusReferences(documentId:) either-side filter (+3 tests); governanceGraph .only()/select_related crash fixed via values_list (found live against the 134-doc S-1 corpus). Verified: 6 new Playwright CT tests + 38 backend tests green, tsc/lint/ pre-commit green, full live smoke on dev S-1 + DGCL corpora (CTA -> celery weave -> graph; panel row -> Rule 144 text; statute node -> DGCL § 116).
What this is
The reference-web substrate for the corpus intelligence initiative: drop a collection of SEC filings into a corpus and the system detects, normalizes, and resolves every explicit reference — exhibit cross-references become in-app document links, statutory citations become cross-corpus links to the actual section text of the governing law, ingested as first-class corpus documents.
Generated from real data: 5 filing corpora (SpaceX, Fervo Energy, Cerebras, Quantinuum, and 87 Anthropic-linked Form D SPVs — 254 documents), 1,943 references resolved, 1,501/1,726 statutory citations linked to in-system law across 6 authority corpora (DGCL, Securities Act, SEC Rules/17 C.F.R., Exchange Act, ICA, IRC). The amber cascade on the left is 957 Reg D citations from the SPV swarm resolving into Rule 504/506.
What's inside
Engine (
opencontractserver/enrichment/)sec-rule:506(b)); plus exhibit refs, internal section refs, and opt-in defined-term definition sites (599 distinct terms found, 46 shared across companies).OC_REFERENCESrelationships,DocumentRelationshiprollups (these feed Corpus Intelligence home (Phase 1): document graph, insight panel, ask-across-docs #1969'sDocumentGraphGlimpsedirectly), andCorpusReferencerows.Cross-corpus law linking
CorpusReferencemodel (migrationannotations/0078) — cross-document / cross-corpus / external-law links with an indexedcanonical_keyjoin key.AuthorityCorpusBootstrapper: statute sections as keyed text documents (custom_meta.canonical_key), idempotent (skip / version-up on amendment / self-healing restamp), with subsection→section resolution fallback (dgcl:122(17)→dgcl:122).custom_meta.authority_aliases): adding a body of law is a bootstrap call, zero code — pinned by an NYBCL end-to-end test.link_external_references: late-binding upgrade of EXTERNAL citations to RESOLVED cross-corpus links; runs insideapply()and is re-runnable as authorities appear.Analyzer framework
@corpus_analyzer_taskdecorator — corpus-scoped sibling of@doc_analyzer_task(one run per Analysis, task owns its writes, wrapper manages the Analysis lifecycle). Dispatch, startup auto-sync, andanalyzer.W001cover both flavours.run_task_name_analyzerandCorpusAction.Surfacing:
scan_corpus_references/apply_corpus_reference_enrichmentagent tools (approval + write gated), read-onlycorpusReferencesGraphQL query,demo/pipeline (seed JSONs with real statutory text, bootstrap/import/export scripts, standalone D3 visualization).Tests: 110 across extractor/resolver/writer/tools/authorities/linking/analyzer-integration; pre-commit + mypy + system checks green.
This PR deliberately does not touch #1969. The cross-corpus graph above is a standalone D3 artifact today; the data layer it reads (
CorpusReference+DocumentRelationship) is all in this PR. Once #1969 lands, the path in-app is:governanceGraphGraphQL query — shipped in this PR (bd0b01d16): corpus-scoped node-link query overDocumentRelationship+ resolvedCorpusReferencerows, mirroringdemo/export_governance_graph.py. Assembly inGovernanceGraphService(visibility-enforced: invisible targets degrade to ghost nodes, invisible sources drop their edges), relay-id encoding in the resolver, degree-capped atGOVERNANCE_GRAPH_MAX_NODES. Global (cross-corpus-home) variant remains future work.DocumentGraphGlimpse(not modifying it); the demo's deterministic D3 composition ports directly.corpus_reference_enrichmentanalyzer so dropping documents into a corpus grows the web automatically (machinery in this PR; needs a UI affordance + default-action decision).link_urls already route in-app; a reference side-panel (inbound/outbound citations per document, powered bycorpusReferences) closes the loop.Test plan
pytest opencontractserver/tests/test_enrichment_*.py opencontractserver/tests/test_analyzer_app_coverage.py -n 0— 110 passingdocs/test_scripts/corpus_reference_enrichment.mdanddemo/)